18 октября 2024

Блог

Тестирование

TestContainers: интеграционное тестирование без стресса

E2E - тесты - отличный инструмент, чтобы идти в прод и не беспокоится о том, что что-то сломается. Мы в TE активно его используем и готовы делиться опытом. При написании тестов мы использовали TestContainers на реальных проектах и посчитали инструмент очень удобным. После чего нам захотелось рассказать о нем подробнее и показать его возможности.

Пирамида тестов

Модель тестирования простыми словами – это группировка тестов по уровню их детализации и назначению. Ее рисуют по-разному, включая три или четыре уровня тестирования.

11В самом низу пирамиды Unit – тесты, где мы, как разработчики, тестируем модули, классы или функции, которые написали. На этом уровне вместо зависимостей используются заглушки.

Далее идут интеграционные тесты, они чуть сложнее. На этом уровне мы тестируем работу нашего модуля совместно с внешними зависимостями. Например, работа с БД.

Системное тестирование подразумевает проверку взаимодействия тестируемого ПО с системой по функциональным и нефункциональным требованиям, E2E-тесты - тестирование всей системы с учетом требований и информации о способах использовании ПО.

Разберем детальнее этап интеграционных тестов.

Существует несколько способов реализации интеграционных тестов:

Docker-Compose

Docker-Compose — это инструмент для запуска приложений, состоящих из нескольких контейнеров. Этот инструмент является универсальным. Параметры для запуска контейнеров описываются в docker-compose.yml. Для запуска тестов у docker-compose есть следующие проблемы:

  1. Необходимо не просто запустить контейнер, но и дождаться готовности приложения внутри контейнера работать; 
  2. В случае, если тесты завершены аварийно, контейнеры могут остаться работать, пока не будут остановлены или уничтожены кем-то вручную.

22

(пример файла docker-compose.yml) 

Развертывание «удаленно» и использование «окружения» для тестирования.

В данном случае создается окружение, адрес которого мы прописываем в настройках (например, connectionString для БД, URL для микросервиса). Данный способ позволяет делегировать настройку данного окружения другим командам и экономит время на выполнение тестов. Однако, у такого способа есть недостатки:

  1. Окружение может быть недоступно или иметь закрытый доступ. Например, Вы отладили тесты из сети TE, а при прогоне в гитлабе падают тесты, т. к. не могут подключиться к БД.
  2. Окружение необходимо чистить между тестами или прогонами тестов. Если этого не делать, то тесты могут упасть из-за существующих данных (например, в БД). Это приводит к п.3.
  3. Прогоны тестов в рамках одного окружения необходимо ставить в очередь. Необходимость возникает, так как данные, которые были созданы в рамках одного прогона тестов могут повлиять на другие прогоны.

In Memory DB (NuGet Microsoft.EntityFrameworkCore.InMemory)

Как пример частного случая я выбрал тесты с использованием InMemory, так как интеграционные тесты с БД — это частый случай использования TestContainers и аналогов. InMemory предлагает провайдер, который позволяет как бы держать какую-то базу данных в памяти. В действительности же никакой базы не создается. На сегодня даже сама документация Microsoft не рекомендует данный способ.

У этого способа есть альтернатива - использование SQLite in-memory. Действительно, в таком случае БД уже существует. Однако, мы на проектах часто используем PostgreSQL, MSSQL и другие. Вот основной недостаток данных способов: мы можем поймать исключение там, где тест покажет «Пройдено», так как проблема будет находиться не в коде с EF Core, а в трансляции его в SQL. Ни один провайдер не гарантирует реализацию 100% операций, которые мы можем описать с помощью C# и EF Core.

Что такое TestContainers?

TestContainers – это библиотека, которая позволяет нам поднимать необходимые для тестов Docker-контейнеры, а также их уничтожать. Библиотека помогает нам реализовывать интеграционные тесты, т.е. тестировать взаимодействие нашего приложения с БД, брокерами сообщений или другими сервисами.

На картинках с пирамидами тестов я показал, на каком уровне появляется необходимость использовать реальные сервисы. Здесь мы можем использовать одну из альтернатив, в том числе, TestContainers:

33Нюансы интеграционных тестов

Для запуска интеграционных тестов требуется запустить сервисы-зависимости, поддерживать их работу во время запуска тестов и завершить их работу, когда они не нужны. Это все нужно каким-то образом организовать, причем если что-то пошло не так, то тесты упадут, а сервисы могут остаться запущенными и потреблять ресурсы. Как правило, тесты выполняет тест-раннер (т.к. на проектах настроен CI/CD - непрерывная интеграция - методология, целью которой является автоматизация доставки, сборки и тестирования приложения), потому за работу какого-то «лишнего сервиса» может прийти счет от поставщика услуг.

Плюсы и минусы TestContainers

TestContainers сразу закрывает эти нюансы. Он позволяет поднять необходимые контейнеры перед тестом, запустить сам тест, пока контейнеры работают и уничтожить контейнеры, когда прогон теста завершен. Если что-то пошло не так, контейнеры будут уничтожены по таймауту.

Помимо этого, вы пишете тесты, не привлекая специалистов DevOps, например, для помощи с поднятием PostgreSQL для ваших интеграционных тестов, т.к. библиотека уже это делает. При этом вы продолжаете писать на своем «родном» языке программирования и работать с тем фреймворком для тестов, с которым вы хотите работать.

Есть и свои минусы.

Во-первых, это библиотека-зависимость. Если проект связан с государством или заказчик утвердил только определенный набор зависимостей, эту библиотеку, возможно, Вы не сможете использовать.

Во-вторых, постоянно создавая-уничтожая контейнеры, мы повышаем нагрузку на SSD. Это незначительно, однако, если в мире программной инженерии у нас все хорошо с тем, что каждый новый тест создает множество новых контейнеров для себя, то вот с точки зрения электроники это пустая трата ресурсов. Такой минус есть и у других подходах к организации зависимостей, и мы еще поговорим, как в контексте использования TestContainers можно минимизировать эту нагрузку.

Дополнения для TestContainers

Resource reaper. TestContainers использует специальный контейнер, который отвечает за уничтожение контейнеров, которые были созданы в тесте. Его называют RYUK (Рюк), в честь Бога смерти из аниме «Тетрадь смерти». По умолчанию контейнеры уничтожаются, однако их можно либо переиспользовать (поиск идет по названию) или просто не удалять (полезно при отладке, можно почитать логи). Создание resource reaper’a можно отключить, но тогда нужно самим уничтожать запущенные контейнеры.

NuGet-пакеты (.NET). Есть много готовых пакетов для TestContainers, которые позволяют развернуть необходимые нам зависимости для тестов. Примеры:

  • Testcontainers - для создания произвольного контейнера 
  • Testcontainers.Elasticsearch - elasticsearch 
  • Testcontainers.MongoDb - Mongo 
  • Testcontainers.MsSql - Microsoft SQL Server 
  • Testcontainers.PostgreSql - PostgreSQL 
  • Testcontainers.RabbitMQ - RabbitMQ 
  • Testcontainers.Kafka - Kafka 

Здесь хочется добавить, что TestContainers представлена на многих языках и у каждого из них есть свои реализации таких пакетов. Например, инфраструктура TestContainers для Java богаче, чем для .NET.

Теперь от теории постепенно перейдем к практике.

Пример №1:

Стратегии изоляции тестов

В мире .NET есть два ведущих фреймворка, с помощью которых мы пишем тесты – Nunit и xUnit. На их основе мы можем использовать одну из нескольких стратегий для работы с тестовой инфраструктурой: создавать ее на каждый тест, на каждый класс или на весь запуск тестов. xUnit позволяет также выделить коллекцию тестов.

В качестве примера я реализовал некий сервис пользователей с CRUD-операциями. У нас есть БД Postgres, куда мы складываем пользователей и нам нужно написать интеграционные тесты. Они везде одинаковые, я поделил их на несколько файлов с классами для наглядности.

Далее я установил в проект пакет Testcontainers.PostgreSql, создал контейнер PostgreSQL, вызвал методы StartAsync и StopAsync, когда мне необходимо. И между примерами как раз будет разница, где создаются и запускаются контейнеры. Давайте внимательнее на нее посмотрим на примере xUnit:

Если хотим создавать контейнеры заново на каждый тест, то мне нужно реализовать интерфейс IAsyncLifetime. В методе InitializeAsync создаем и запускаем контейнер, в методе DisposeAsync – выполняем очистку.

44

Если я хотим создавать зависимости на каждый класс, то для этого я создаем TargetDbFixture, в котором реализуем IAsyncLifetime. В базовом классе реализую IClassFixture, прокидываем TargetDbFixture в конструктор базового класса тестов, берем ConnectionString и используем. Здесь же я выполняем очистку БД между запусками тестов.

55

66

Теперь правильный пример для тестов с БД. Можно пометить базовый класс таким образом, что он относится к определенной коллекции тестов и TargetDbFixture будет создаваться один раз на коллекцию. Так можно запустить сразу все тесты, сделав их частью одной коллекции. Таким образом мы создадим контейнер только один раз на коллекцию.

77

88

Мы посмотрели различные стратегии, которые относятся к вашему тестовому фреймворку. Здесь важно просто понять, как часто нужно пересоздавать контейнеры. Например, нужно перезапускать контейнеры на каждый тест или на каждый класс? Для тестов, где вы только поднимаете БД, лучше просто почистить базу. Но в других случаях это может вам пригодиться.

К слову, мы только что посмотрели, как использовать готовый контейнер, но что, если мы не нашли нужной реализации и нам нужен какой-то свой?

Создание произвольного контейнера

Предположим, понадобился контейнер Nginx и его нет как отдельной библиотеки. Тогда используются стандартные средства TestContainers и собирается контейнер, указав его образ в виде названия. При запуске тестов будет выполнена команда docker pull с названием образа, образ скачается (а это может быть долго) и будет создан нужный контейнер. Затем мы можем запустить его с помощью вызова StartAsync, как в предыдущем примере.

99(пример с основными методами, которые могут понадобиться для создания контейнера) 

Создание образа своего микросервиса

Предположим, нам потребовались тесты посложнее и нам нужно поднять несколько сервисов одновременно. В том числе, наши собственные. Для этого сначала потребуется создать образ. Здесь нам потребуется Dockerfile и путь к нему. Мы можем собрать образ для контейнера, например, следующим образом:

1010

Создание контейнера микросервиса

Наш образ мы можем указать при создании контейнера, т.к. у TestContainers есть перегрузка метода WithImage с нашим образом. Вместе с образом необходимо передать конфигурации для микросервиса. Делается это очень просто - вызываем метод WithEnvironment и передаем. Помимо этого, если для запуска контейнера требуются работающие другие контейнеры, то мы тоже можем их передать в метод DependsOn и контейнер запустится только в том случае, если зависимости были запущены.

1111

Пример №3:

API-тест с микросервисами, RabbitMQ и БД В данном примере мы имитируем бизнес-процесс создания карточки сотрудника через метод POST, после чего получаем ее методом GET. При создании портал и микросервис общаются через шину, а при чтении – по HTTP. Микросервис Employees имеет доступ к БД сотрудников для операций чтения и записи.

В качестве последнего примера были реализованы два похожих примера:

  1. API-тест в сложной системе;
  2. Selenium-тест в сложной системе.

1212

1313

В данных примерах нас интересует, как перед тестами развернуть приложение. Приложим код Startup метода с комментариями для Selenium-теста.

1414

Сначала нам необходимо собрать образы наших микросервисов, для чего нам понадобится путь к Dockerfile. Затем нужно запустить контейнеры, без которых наши микросервисы работать не смогут. В данном случае это RabbitMQ и PostgreSQL. После этого необходимо запустить контейнеры наших микросервисов (приложение на Angular собирается вместе с Portal в виде статики). Контейнер WebDriver нам нужно запустить, чтобы создать объект driver.

Наконец мы выполняем стандартные для Selenium-теста операции: создаем driver и pageObject. Инициализация завершена, далее будет запускаться тест.

В примере с Selenium-тестами обратите внимание, что тестовый проект может быть реализован на другом языке. Например, если тестировщик знает Java, он может развернуть .NET микросервисы и реализовать тесты на Java.

Разница между реализациями

Между реализациями TestContainers для разных языков отличается. На Java есть поддержка Docker Compose, в отличие .NET. Некоторые реализации не имеют даже сайта с документацией, а ссылка с сайта ведет сразу на github. Чтобы иметь актуальную информацию о вашей реализации, лучше смотреть в репозиторий github.

Итог

Вы увидели основные возможности TestContainers - посмотрели, какие есть реализации у популярных сервисов, научились поднимать произвольный контейнер и создавать образ своего микросервиса. При этом мы оставались на родном языке программирования.

К каким пришли выводам:

  • Инструмент очень простой для изучения и использования. 
  • Если нужно написать интеграционные тесты только с БД, то использование TestContainers – это хорошая практика. 
  • Существуют несколько разных стратегий изоляции тестов, нет единственной «верной» для всех. 
  • С TestContainers есть возможность запускать сложные системы.

Теперь вы понимаете, что это такое и, возможно, попробуете применить на практике. Testcontainers – это очень сильный и очень простой инструмент для ваших тестов, но при этом он не отменяет и не заменяет другие варианты, поэтому действуйте, полагаясь на свои потребности и предпочтения.

Илья Ершов, .Net разработчик


Ершов Илья